本文深入探讨了`ExpressionChangedAfterItHasBeenCheckedError`错误的原因及其解决方案。通过分析Angular的变更检测机制,详细解释了该错误的发生条件,并提供了多种有效的应对策略,帮助开发者在实际开发中避免这一常见问题。
原文链接:
Everything you need to know about the ExpressionChangedAfterItHasBeenCheckedError
error
关于
ExpressionChangedAfterItHasBeenCheckedError
,还能够参考这篇文章,而且文中有
youtube
视频解说:
Angular Debugging “Expression has changed after it was checked”: Simple Explanation (and Fix)
近来 stackoverflow 上险些天天都有人提到 Angular 抛出的一个毛病:ExpressionChangedAfterItHasBeenCheckedError
,平常提出这个题目的 Angular 开辟者都不明白变动检测(change detection)的原理,不明白为什么发作这个毛病的数据更新搜检是必需的,以至许多开辟者以为这是 Angular 框架的一个 bug(译者注:Angular 供应变动检测功用,包含自动触发和手动触发,自动触发是默许的,手动触发是在运用 ChangeDetectionStrategy.OnPush
封闭自动触发的状况下见效。怎样手动触发,参考 Triggering change detection manually in Angular)。固然不是了!实在这是 Angular 的正告机制,防备由于模子数据(model data)与视图 UI 不一致,致使页面上存在毛病或过期的数据展示给用户。
本文将诠释激发这个毛病的内涵缘由,检测机制的内部原理,供应致使这个毛病的配合行动,并给出修复这个毛病的处理方案。末了章节诠释为什么数据更新搜检是云云主要。
It seems that the more links to the sources I put in the article the less likely people are to recommend it ?. That’s why there will be no reference to the sources in this article.(译者注:这是作者的吐槽,不翻译)
相干变动检测行动
一个运转的 Angular 递次实际上是一个组件树,在变动检测时期,Angular 会依据以下递次搜检每个组件(译者注:这个列表称为列表 1):
- 更新一切子组件/指令的绑定属性
- 挪用一切子组件/指令的三个生命周期钩子:
ngOnInit
,OnChanges
,ngDoCheck
- 更新当前组件的 DOM
- 为子组件执行变动检测(译者注:在子组件上反复上面三个步骤,顺次递归下去)
- 为一切子组件/指令挪用当前组件的
ngAfterViewInit
生命周期钩子
在变动检测时期还会有其他操纵,能够参考我写的文章:《Everything you need to know about change detection in Angular》 。
在每一次操纵后,Angular 会记下执行当前操纵所须要的值,并存放在组件视图的 oldValues
属性里(译者注:Angular Compiler 会把每个组件编译为对应的 view class,即组件视图类)。在一切组件的搜检更新操纵完成后,Angular 并非立时接着执行上面列表中的操纵,而是会最先下一次 digest cycle,即 Angular 会把来自上一次 digest cycle 的值与当前值比较(译者注:这个列表称为列表 2):
- 搜检已传给子组件用来更新其属性的值,是不是与当前将要传入的值雷同
- 搜检已传给当前组件用来更新 DOM 值,是不是与当前将要传入的值雷同
- 针对每个子组件执行雷同的搜检(译者注:就是假如子组件另有子组件,子组件会继承执行上面两步的操纵,顺次递归下去。)
记着这个搜检只在开辟环境下执行,我会在后文诠释缘由。
让我们一同看一个简朴示例,假定你有一个父组件 A
和一个子组件 B
,而 A
组件有 name
和 text
属性,在 A
组件模板里运用 name
属性的模板表达式:
template: '{{name}}'
同时,另有一个 B
子组件,并将 A
父组件的 text
属性以输入属性绑定体式格局传给 B
子组件:
@Component({
selector: 'a-comp',
template: `
{{name}}
`
})
export class AComponent {
name = 'I am A component';
text = 'A message for the child component`;
那末当 Angular 执行变动检测的时刻会发作什么呢?首先是从搜检父组件 A
最先,依据上面列表 1 列出的行动,第一步是更新一切子组件/指令的绑定属性(binding property)
,所以 Angular 会盘算 text
表达式的值为 A message for the child component
,并将值向下传给子组件 B
,同时,Angular 还会在当前组件视图中存储这个值:
view.oldValues[0] = 'A message for the child component';
第二步是执行上面列表 1 列出的执行几个生命周期钩子。(译者注:即挪用子组件 B
的 ngOnInit
,OnChanges
,ngDoCheck
这三个生命周期钩子。)
第三步是盘算模板表达式 {{name}}
的值为 I am A component
,然后更新当前组件 A
的 DOM,同时,Angular 还会在当前组件视图中存储这个值:
view.oldValues[1] = 'I am A component';
第四步是为子组件 B
执行以上第一步到第三步的雷同操纵,一旦 B
组件搜检终了,那本次 digest loop 完毕。(译者注:我们晓得 Angular 递次是由组件树组成的,当前父组件 A
组件做了第一二三步,完预先子组件 B
一样会去做第一二三步,假如 B
组件另有子组件 C
,一样 C
也会做第一二三步,一向递归下去,直到当前树枝的最末端,即末了一个组件没有子组件为止。这一次历程称为 digest loop。)
假如处于开辟者形式,Angular 还会执行上面列表 2 列出的 digest cycle 轮回核对。如今假定当 A
组件已把 text
属性值向下传入给 B
组件并保留该值后,这时候 text
值突变成 updated text
,如许在 Angular 运转 digest cycle 轮回核对时,会执行列表 2 中第一步操纵,即搜检当前digest cycle 的 text 属性值与上一次时的 text 属性值是不是发作变化:
AComponentView.instance.text === view.oldValues[0]; // false
'A message for the child component' === 'updated text'; // false
结果是发作变化,这时候 Angular 会抛出 ExpressionChangedAfterItHasBeenCheckedError
毛病。
列表 1 中第三步操纵也一样会执行 digest cycle 轮回搜检,假如 name
属性已在 DOM 中被衬着,而且在组件视图中已被存储了,那这时候 name
属性值突变一样会有一样毛病:
AComponentView.instance.name === view.oldValues[1]; // false
'I am A component' === 'updated name'; // false
你能够会问上面提到的 text
或 name
属性值发作突变,这会发作么?让我们一同往下看。
属性值突变的缘由
属性值突变的罪魁祸首是子组件或指令,一同看一个简朴证实示例吧。我会先运用最简朴的例子,然后举个更切近实际的例子。你能够晓得子组件或指令能够注入它们的父组件,假定子组件 B
注入它的父组件 A
,然后更新绑定属性 text
。我们在子组件 B
的 ngOnInit
生命周期钩子中更新父组件 A
的属性,这是由于 ngOnInit
生命周期钩子会在属性绑定完成后触发(译者注:参考列表 1,第一二步操纵):
export class BComponent {
@Input() text;
constructor(private parent: AppComponent) {}
ngOnInit() {
this.parent.text = 'updated text';
}
}
果然会报错:
Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'A message for the child component'. Current value: 'updated text'.
如今我们再一样转变父组件 A
的 name
属性:
ngOnInit() {
this.parent.name = 'updated name';
}
纳尼,竟然没有报错!!!怎么能够?
假如你往上翻看列表 1 的操纵执行递次,你会发明 ngOnInit
生命周期钩子会在 DOM 更新操纵执行前触发,所以不会报错。为了有报错,看来我们须要换一个生命周期钩子,ngAfterViewInit
是个不错的选项:
export class BComponent {
@Input() text;
constructor(private parent: AppComponent) {}
ngAfterViewInit() {
this.parent.name = 'updated name';
}
}
还好,终究有报错了:
AppComponent.ngfactory.js:8 ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'I am A component'. Current value: 'updated name'.
固然,实在天下的例子会越发庞杂,转变父组件属性从而激发 DOM 衬着,平常间接是由于运用效劳(services)或可观察者(observables)激发的,不过根本缘由照样一样的。
如今让我们看看实在天下的案例吧。
同享效劳(Shared service)
这个形式案例可检察代码 plunker。这个递次设计为父子组件有个同享的效劳,子组件修正了同享效劳的某个属性值,相应式地致使父组件的属性值发作转变。我把它称为非直接父组件属性更新,由于不像上面的示例,它显著不是子组件马上转变父组件属性值。
同步事宜播送
这个形式案例可检察代码 plunker。这个递次设计为子组件抛出一个事宜,而父组件监听这个事宜,而这个事宜会激发父组件属性值发作转变。同时这些属性值又被父组件作为输入属性绑定传给子组件。这也黑白直接父组件属性更新。
动态组件实例化
这个形式有点差别于前面两个影响的是输入属性绑定,它激发的是 DOM 更新从而抛出毛病,可检察代码 plunker。这个递次设计为父组件在 ngAfterViewInit
生命周期钩子动态增加子组件。由于增加子组件会触发 DOM 修正,而且 ngAfterViewInit
生命周期钩子也是在 DOM 更新后触发的,所以一样会抛出毛病。
处理方案
假如你细致检察毛病形貌的末了部份:
Expression has changed after it was checked. Previous value:… Has it been created
in a change detection hook ?
依据上面形貌,平常的处理方案是运用准确的生命周期钩子来建立动态组件。比方上面建立动态组件的示例,其处理方案就是把组件建立代码移到 ngOnInit
生命周期钩子里。只管官方文档说 ViewChild
只要在 ngAfterViewInit
钩子后才有用,然则当建立视图时它就已填入了子组件,所以在初期阶段就可用。(译者注:Angular 官网说的是 View queries are set before the ngAfterViewInit callback is called
,就已说清楚明了 ViewChild
是在 ngAfterViewInit
钩子前见效,不明白作者为啥要说以后才见效。)
假如你 google 下就晓得处理这个毛病平常有两种体式格局:异步更新属性和手动强制变动检测。只管我列出这两个处理方案,但不发起这么去做,我将会诠释缘由。
异步更新
这里须要注重的事变是变动检测和核对轮回(verification digests)都是同步的,这意味着假如我们在核对轮回(verification loop)运转时去异步更新属性值,会致使毛病,测试下吧:
export class BComponent {
name = 'I am B component';
@Input() text;
constructor(private parent: AppComponent) {}
ngOnInit() {
setTimeout(() => {
this.parent.text = 'updated text';
});
}
ngAfterViewInit() {
setTimeout(() => {
this.parent.name = 'updated name';
});
}
}
实际上没有抛出毛病(译者注:耍我呢!),这是由于 setTimeout()
函数会让回调在下一个 VM turn 中作为宏观使命(macrotask)被执行。假如运用 Promise.then
回调来包装,也能够在当前 VM turn 中执行完同步代码后,紧接着在当前 VM turn 继承执行回调:(译者注:VM turn 就是 Virtual Machine Turn,即是 browser task,这涉及到 JS 引擎怎样执行 JS 代码的学问,这又是一块大学问,不详述,有兴致能够参考这篇典范文章 Tasks, microtasks, queues and schedules ,或许这篇详细形貌的文档 从浏览器多历程到JS单线程,JS运转机制最全面的一次梳理 。)
Promise.resolve(null).then(() => this.parent.name = 'updated name');
与宏观使命(macrotask)差别,Promise.then
会把回调构形成微观使命(microtask),微观使命会在当前同步代码执行完后再紧接着被执行,所以在核对以后会紧接着更新属性值。想要更多进修 Angular 的宏观使命和围观使命,能够检察我写的 I reverse-engineered Zones (zone.js) and here is what I’ve found 。
假如你运用 EventEmitter
你能够传入 true
参数完成异步:
new EventEmitter(true);
强制式变动检测
另一种处理方案是在第一次变动检测和核对轮回阶段之间,再一次迫使 Angular 执行父组件 A
的变动检测(译者注:由于 Angular 先是变动检测,然后核对轮回,所以这段意义是变动检测完后,再去变动检测)。最佳时期是在 ngAfterViewInit
钩子里去触发父组件 A
的变动检测,由于这个父组件的钩子函数会在一切子组件已执行完它们本身的变动检测后被触发,而恰恰是子组件做它们本身的变动检测时能够会转变父组件属性值:
export class AppComponent {
name = 'I am A component';
text = 'A message for the child component';
constructor(private cd: ChangeDetectorRef) {
}
ngAfterViewInit() {
this.cd.detectChanges();
}
很好,没有报错,不过这个处理方案依然有个题目。假如我们为父组件 A
触发变动检测,Angular 依然会触发它的一切子组件变动检测,这能够重新会致使父组件属性值发作转变。
为什么须要轮回核对(verification loop)
Angular 执行的是从上到下的单向数据流,当父组件转变值已被同步后(译者注:即父组件模子和视图已同步后),不允许子组件去更新父组件的属性,如许确保在第一次 digest loop 后,全部组件树是稳固的。假如属性值发作转变,那末依靠于这些属性的消费者(译者注:即子组件)就须要同步,这会致使组件树不稳固。在我们的示例中,子组件 B
依靠于父组件的 text
属性,每当 text
属性转变时,除非它已被传给 B
组件,不然全部组件树是不稳固的。关于父组件 A
中的 DOM 模板也一样原理,它是 A
模子中属性的消费者,并在 UI 中衬着出这些数据,假如这些属性没有被实时同步,那末用户将会在页面上看到毛病的数据信息。
数据同步历程是在变动检测时期发作的,特别是列表 1 中的操纵。所以假如当同步操纵执行终了后,在子组件中去更新父组件属性时,会发作什么呢?你将会获得不稳固的组件树,如许的状况是不可测的,大多数时刻你将会给用户展示毛病的信息,而且很难调试。
那为什么不比及组件树稳固了再去执行变动检测呢?答案很简答,由于它能够永久不会稳固。假如把子组件更新了父组件的属性,作为该属性转变时的相应,那将会无穷轮回下去。固然,正如我之前说的,不管是直接更新照样依靠的状况,这都不是重点,然则在实际天下中,更新照样依靠平常都黑白直接的。
风趣的是,AngularJS 并没有单向数据流,所以它会试图想办法去让组件树稳固。然则它会常常致使谁人有名的毛病 10 $digest() iterations reached. Aborting!
,去谷歌这个毛病,你会惊奇发明关于这个毛病的题目有许多。
末了一个题目你能够会问为什么只要在开辟形式下会执行 digest cycle 呢?我猜能够由于比拟于一个运转毛病,不稳固的模子并非个大题目,毕竟它能够在下一次轮回搜检数据同步后变得稳固。但是,最好能在开辟阶段注重能够发作的毛病,总比在临盆环境去调试毛病要好很多。